JavaScript 学习笔记:错误处理

通常,对于 JavaScript 脚本而言,如果运行脚本时出现错误会挂掉,即引擎会在错误代码处停下来,不会继续执行后续代码,而是将错误信息打印到控制台。比如:

1
2
3
4
let a = 1;
console.log(b); (*)
console.log(a);
// Uncaught ReferenceError: b is not defined

上面的代码中,执行到 (*) 这一行便会停下来,将错误信息打印到控制台。

try…catch 结构

但是,有一种语法结构 try...catch 允许我们捕获错误并作出相应的处理,这样脚本在出现错误时不会挂掉,而是执行我们设定的错误处理代码。
我们来看两个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {

alert('Start of try runs'); // (1) <--

// ...no errors here

alert('End of try runs'); // (2) <--

} catch(err) {

alert('Catch is ignored, because there are no errors'); // (3)

}

alert("...Then the execution continues");

上面的代码中,因为 try 语句块没有错误,所以 catch 语句块内的代码会被忽略,不会执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {

alert('Start of try runs'); // (1) <--

lalala; // error, variable is not defined!

alert('End of try (never reached)'); // (2)

} catch(err) {

alert(`Error has occured!`); // (3) <--

}

alert("...Then the execution continues");

上面的代码中,由于 try 语句块内存在错误:变量未定义,所以 try 语句块内这一行之后的代码都不会执行,直接跳转到 catch 语句块内执行错误处理代码。

try…catch 只能捕获运行时错误

所谓运行时错误 runtime-error,是指有效的 JavaScript 代码,即 JavaScript 引擎可以正确解析的代码。对于一个 JavaScript 脚本,引擎首先会解析它,接着执行它。如果出现解析时错误,通常是语法错误,引擎会直接报错,因为引擎这时无法读懂代码,自然地,try..catch 结构不可能捕获到解析错误。比如:

1
2
3
4
5
try {
{{{{{{{{{{{{
} catch(e) {
alert("The engine can't understand this code, it's invalid");
}

try…catch 是同步执行的

在诸如定时器 setTimeout 等异步代码中发生错误,try...catch 结构无法捕获错误。比如:

1
2
3
4
5
6
7
try {
setTimeout(function() {
noSuchVariable; // script will die here
}, 1000);
} catch (e) {
alert( "won't work" );
}

原因在于,setTimeout 的回调函数在执行时,引擎实际上已经离开了 try...catch 结构体。要捕获类似这样的错误,需要这样做:

1
2
3
4
5
6
7
setTimeout(function() {
try {
noSuchVariable; // try..catch handles the error!
} catch (e) {
alert( "error is caught here!");
}
})

错误对象

当发生运行时错误时,引擎会创建一个错误对象,里面包含了有关这次错误的信息。该错误对象会被当作参数传递给 catch 语句:

1
2
3
4
5
6
7
8
9
10
11
try {
lalala; // error, variable is not defined!
} catch(err) { // <-- the "error object", could use another word instead of err
alert(err.name); // ReferenceError
alert(err.message); // lalala is not defined
alert(err.stack); // ReferenceError: lalala is not defined at ...

// Can also show an error as a whole
// The error is converted to string as "name: message"
alert(err); // ReferenceError: lalala is not defined
}

错误对象主要有 2 个属性:

  • name 错误的名称,对于未定义的变量而言,是引用错误 ReferenceError
  • message 有关错误详情的文本信息。

还有一个非标准但是被广泛采用的属性:

  • stack 主要用作调试,包含了导致错误的调用栈跟踪。

实例

让我们来看一个实际的例子:解析从服务器获取的 JSON 数据。正常的情况下,应该是这样的:

1
2
3
4
5
6
7
const json = '{"name":"John", "age": 30}'; // data from the server

const user = JSON.parse(json); // convert the text representation to JS object

// now user is an object with properties from the string
alert( user.name ); // John
alert( user.age ); // 30

JSON 格式错误

但是实际情况往往复杂多变,首先考虑一种情况,假如 JSON 数据不合法(格式错误,无法被正确解析),那么脚本运行到解析 JSON 数据时将会直接挂掉。这显然不是我们想要的结果,这也会让用户非常困惑。我们可以使用 try...catch 来进行错误处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
const json = "{ bad json }";

try {

let user = JSON.parse(json); // <-- when an error occurs...
alert( user.name ); // doesn't work

} catch (e) {
// ...the execution jumps here
alert( "Our apologies, the data has errors, we'll try to request it one more time." );
alert( e.name );
alert( e.message );
}

抛出错误

再考虑另一种情况:JSON 格式是对的,但是不包含我们需要的字段,在这里是 name 字段:

1
2
3
4
5
6
7
8
9
10
const json = '{ "age": 30 }'; // incomplete data

try {

let user = JSON.parse(json); // <-- no errors
alert( user.name ); // no name!

} catch (e) {
alert( "doesn't execute" );
}

对于这种情况,我们可以使用 throw 操作符来抛出错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const json = '{ "age": 30 }'; // incomplete data

try {

const user = JSON.parse(json); // <-- no errors

if (!user.name) {
throw new SyntaxError("Incomplete data: no name"); // (*)
}

alert( user.name );

} catch(e) {
alert( "JSON Error: " + e.message ); // JSON Error: Incomplete data: no name
}

重新抛出错误

接着考虑更复杂的情况,除了 JSON 数据字段缺失的错误,假如 try 语句块内还有其他的错误,比如未定义的变量,如何在 catch 语句块内处理这种情况?接着上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const json = '{ "age": 30 }'; // incomplete data

try {

const user = JSON.parse(json); // <-- no errors

lalala; // (*) undefined variable

if (!user.name) {
throw new SyntaxError("Incomplete data: no name"); //
}

alert( user.name );

} catch(e) {
alert( "JSON Error: " + e.message ); // JSON Error: lalala is not defined
}

上面代码标有 (*) 的一行有一个未定义的变量,于是引擎会创建错误对象并跳转到 catch 语句块。需要明确的一点是,catch 会从 try 中捕获所有的错误。对于类似上面的例子,解决思路很简单:catch 语句块应该只处理它知道的错误并重新抛出其他错误。

这一过程大致如下:

  1. catch 会捕获 try 内的所有错误。
  2. catch 语句块内,我们通过错误对象的 name 属性来分析错误。
  3. 只处理我们知道如何处理的错误,重新抛出其他错误。

针对上面的提到的同时存在未定义变量错误和 JSON 语法错误,我们只需要处理 JSON 语法错误,而将其他错误重新抛出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const json = '{ "age": 30 }'; // incomplete data

try {

const user = JSON.parse(json); // <-- no errors

lalala; // (*) undefined variable

if (!user.name) {
throw new SyntaxError("Incomplete data: no name");
}

alert( user.name );

} catch(e) {
if (e.name === 'SyntaxError) {
alert( "JSON Error: " + e.message );
} else {
throw e; // rethrow (*)
}
}

上面代码中,try...catch 只处理了它关心的 JSON 语法错误,而将其他错误重新抛出。那么其他错误最终到哪里去了呢?

两种可能:如果外部代码没有使用 try...catch 来捕获错误,那么会导致脚本挂掉;如果外部代码使用了 try...catch 结构,则会捕获重新抛出的错误。如下面代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function readData() {
const json = '{ "age": 30 }';

try {
// ...
blabla(); // error!
} catch (e) {
// ...
if (e.name != 'SyntaxError') {
throw e; // rethrow (don't know how to deal with it)
}
}
}

try {
readData();
} catch (e) {
alert( "External catch got: " + e ); // caught it!
}

上面的代码中,内层的 try...catch 只处理了语法错误,其他的错误都由外层的 try...catch 来处理。

注意事项

  1. try...catch...finally 语句块内声明的变量只在该语句块没可见。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    let num = +prompt("Enter a positive integer number?", 35)

    let diff, result; // 注意这里变量都声明在 try...catch...finally 语句块之外

    function fib(n) {
    if (n < 0 || Math.trunc(n) != n) {
    throw new Error("Must not be negative, and also an integer.");
    }
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
    }

    let start = Date.now();

    try {
    result = fib(num);
    } catch (e) {
    result = 0;
    } finally {
    diff = Date.now() - start;
    }

    alert(result || "error occured");

    alert( `execution took ${diff}ms` );
  2. finally 语句块总是会执行,即使 try 语句块内有显式的返回:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function func() {

    try {
    return 1;

    } catch (e) {
    /* ... */
    } finally {
    alert( "finally" );
    }
    }

    alert( func() ); // first works alert from finally, and then this one

全局捕获错误

有一个不属于语言规范,但是各大浏览器都实现了的全局捕获错误的回调函数 window.onerror。它的主要作用不是为了让脚本可以继续执行,而是通常用作错误报告,即将错误信息发送给开发者。在页面中插入下面的脚本,即可实现错误报告的效果:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
window.onerror = function(message, url, line, col, error) {
const err = `${message}\n At ${line}:${col} of ${url}`;
sendToDevelop(err); // 发送给开发者
};

function readData() {
badFunc(); // 此处发生错误
}

readData();
</script>

定制和扩展错误

在实际开发中,语言内置的几个标准错误类,比如 ErrorSyntaxErrorTypeErrorReferenceError 等,可能不足以满足我们在特定情况下的需要。比如在进行网络请求操作时我们可能需要 HttpError,在进行数据库操作时我们可能需要 DbError,对于搜索操作可能需要 NotFoundError 等。我们可以通过继承通用错误类 Error 来定制我们需要的错误类,这被认为是最佳实践。有以下优点:

  • 可以继承 messagenamestack 这些基础的错误属性。
  • 可以使用 inctanceof 运算符来判断错误类型。
  • 便于之后的多级错误类型继承的形成。

当然,对于不同的错误类,我们可以添加额外所需的属性,比如对于 HttpError,可以添加 statusCode 属性,它的值可能是 404500 等。

扩展错误实例

让我们来看一个读取 JSON 格式的用户数据的例子。假定我们期望的用户数据是这样的:

1
const json = `{ "name": "John", "age": 30 }`;

先做一点铺垫,内置的通用错误类 Error 的伪代码可能是这样的:

1
2
3
4
5
6
7
8
// The "pseudocode" for the built-in Error class defined by JavaScript itself
class Error {
constructor(message) {
this.message = message;
this.name = "Error"; // (different names for different built-in error classes)
this.stack = <nested calls>; // non-standard, but most environments support it
}
}

为了将 JSON 数据字段缺失的错误单独处理,我们定制一个单独的 ValidationError 错误类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError"
}
}

// 模拟一个错误
const test = function() {
throw new ValidationError("Whoops!");
}

try {
test();
} catch(err) {
alert(err.message); // Whoops!
alert(err.name); // ValidationError
alert(err.stack); // a list of nested calls with line numbers for each
}

接着我们将它用在读取用户数据的例子上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "Validation";
}
}

// Usage
const readUser = function (json) {
const user = JSON.parse(json);

if (!user.name) {
throw new ValidationError("no field: name");
}

if (!user.age) {
throw new ValidationError("no field: age");
}

return user;
}

const json = `{ "age": 29 }`;

// Working example with try..catch
try {
readUser(json);
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Invalid data: ${error.message}`); // Invalid data: No field: name
} else if (error instanceof SyntaxError) {
console.log(`JSON syntax error: ${error.message}`);
} else {
throw error; // unknown error, rethrow it
}
}

注意上面代码使用 instanceof 运算符来判断错误类型的做法。

进一步扩展错误类

上面的 ValidationError 错误类还是过于通用,我们在它的基础上继续扩展一个更具体的属性缺失错误类 PropertyRequireError:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "Validation";
}
}

class PropertyRequireError extends ValidationError {
constructor(property) {
super(`No property: ${property}`);
this.property = property;
this.name = "PropertyRequireError";
}
}

// Usage
const readUser = function (json) {
const user = JSON.parse(json);

if (!user.name) {
throw new PropertyRequireError("name");
}

if (!user.age) {
throw new PropertyRequireError("age");
}

return user;
}

const json = `{ "age": 29 }`;

// Working example with try..catch
try {
readUser(json);
} catch (error) {
if (error instanceof PropertyRequireError) {
console.log(`Invalid data: ${error.message}`);
} else if (error instanceof SyntaxError) {
console.log(`JSON syntax error: ${error.message}`);
} else {
throw error; // unknown error, rethrow it
}
}

现在,我们在抛出属性错误的时候只需要传入缺失的属性就可以了。还有一个地方可以优化,每次扩展一个类都需要设置 this.name = ...,可以增加一个继承的层级来专门完成这个任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}

class ValidationError extends MyError { }

class PropertyRequiredError extends ValidationError {
constructor(property) {
super(`No property: ${property}`);
this.property = property;
}
}

// name is correct
alert( new PropertyRequiredError("field").name ); // PropertyRequiredError

包装异常

让我们思考一下,readUser 这个函数的任务是从 JSON 数据读取到我们所需要的用户数据字段。让我们站在 readUser 函数的调用者的角度来思考,我们希望得到的错误信息应该简单清晰,是一个类似 ReadError 这样的错误类。至于错误的具体细节应该封装在这个错误类内部,可能是 JSON 格式错误,可能是属性缺失错误,以及将来可能出现的其他错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class MyError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}

class ReadError extends MyError {
constructor(message, cause) {
super(message);
this.cause = cause;
}
}

class ValidationError extends MyError {
constructor(message) {
super(message);
}
}

class PropertyRequireError extends ValidationError {
constructor(property) {
super(`No property: ${property}`);
this.property = property;
}
}

// 验证用户数据是否缺失属性
const validateUser = function (user) {
if (!user.name) {
throw new PropertyRequireError("name");
}

if (!user.age) {
throw new PropertyRequireError("age");
}
}

const readUser = function (json) {
let user;

try {
user = JSON.parse(json);
} catch (error) {
if (error instanceof SyntaxError) {
throw new ReadError("SyntaxError", error);
} else {
throw error;
}
}

try {
validateUser(user);
} catch (error) {
if (error instanceof ValidationError) {
throw new ReadError("ValidationError", error);
} else {
throw error;
}
}

return user;
}

const json = `{ "age": 29 }`;

try {
readUser(json);
} catch (error) {
if (error instanceof ReadError) {
// readUser 调用者关心的错误
console.log(error);
// 原始错误信息
console.log(error.cause);
} else {
throw error;
}
}

上面代码所使用的方式叫做包装异常 Wrapping Exceptions,是一种在面向对象编程中广泛使用的技巧。